Keyframe manipulator (community edition)

   2320   6   3
User Avatar
Member
308 posts
Joined: Oct. 2016
Offline
Please test this tool on your system. At the moment it does not do anything. The first milestone will be an offset tool. However, it should be possible to extend it to do various other tasks. Bug reports, RFE:s as well as contributions are welcome in return for credits. Have fun and cheers!



def commKeyframeManipulator():
    '''Code for manipulating keyframed parms of a selected node. Version 0.9 alpha. For the Houdini community. Authors: SWest (v0.9 alpha). https://creativecommons.org/licenses/by/4.0/'''

    
    # Code for some initial variables
    actions = ['offset']
    inputs_validated = True
    log_dict_before = {}
    log_dict_after = {}
    keyframes_json_list = []

    
    # Code for getting initerial user inputserial user inputs
    action = actions[0] # hard coded for now
    
    
    # Code for getting all the user's desired nodes to adjust
    def getSelectedNodes():
        selected_nodes = hou.selectedNodes()
        inputs_validated = False  
        if selected_nodes == ():
            message='Please select a node with keyframes and try again. '
            hou.ui.displayMessage(message)
        else:
            inputs_validated = True 
        return inputs_validated, selected_nodes


    # Code for getting user action
    def getSelectedAction():
        '''A function used to present the user with a set of tools related to manipulating keyframes'''

        mesh_source_choice = hou.ui.displayMessage('Please choose desired action:', buttons=('Offset','Cancel'), \
        default_choice=0, close_choice=-1)
        
        if mesh_source_choice == -1:
            action = 'cancel'
        if mesh_source_choice == 0:
            action = 'offset'
        if mesh_source_choice == 1:
            action = 'cancel'
            
        return action
            
        
    # Code for validating user inputs for intended data types
    def getUserOffset():
        message = 'Please type a positive or negative number for frame offset. Example -10 or 15.5'
        offset = hou.ui.readInput(message, buttons=('OK',), severity=hou.severityType.Message, default_choice=0, close_choice=-1, help=None, title=None, initial_contents=None) 
        inputs_validated = True
        try:
            offset = float(offset[1])
        except:
            message='There was an error with the input number. '
            hou.ui.displayMessage(message)
            inputs_validated = False
        return inputs_validated, offset


    # Code for getting all parms with keyframes from the user's selection
    def getParmsFromSelection(selected_nodes):
        '''Return a dict with the parm path and keyframes as a list using json'''
        parms_keyframes_before_dict = {}
        for node in selected_nodes:
            for parm in node.allParms():
                parm_is_scoped = parm.isScoped()
                parm_keyframes_length = 0
                try:
                    parm_keyframes = parm.keyframes()
                except:
                    break
                else:
                    parm_keyframes_length=len(parm_keyframes)
                if(parm_keyframes_length > 0 and parm_is_scoped):
                    keyframes_json_list = []
                    parm_keyframes_before = []
                    #parm_keyframes_after = []
                    for i in range(0,parm_keyframes_length):
                        keyframe_dict=parm.keyframes()[i].asJSON(save_keys_in_frames=True)
                        parm_keyframes_before.append(keyframe_dict.copy())

                    # store this parm with the list of keyframes
                    parms_keyframes_before_dict["'"+parm.path()+"'"] = parm_keyframes_before

        return parms_keyframes_before_dict
        
        
    def getParmsKeyframesChanges(action_dict, parms_keyframes_before_dict):
        '''A function used to calculate the intended changes to keyframes'''
        parms_keyframes_after_dict = {}
        keyframes_after_list = []
        action = action_dict['action']
        if action == 'offset':
            offset = action_dict['offset']
        
        for parm, keyframes in parms_keyframes_before_dict.items():
            for keyframe in keyframes:
                keyframe_dict = keyframe.copy()
                if action == 'offset':
                    frame_in=keyframe_dict['frame']
                    frame_out=frame_in+offset
                    keyframe_dict['frame'] = frame_out

                keyframes_after_list.append(keyframe_dict.copy())
            
            parms_keyframes_after_dict[parm] = keyframes_after_list
        
        return parms_keyframes_after_dict       


    # Code for presenting the intended changes prior to performing them
    def showIntendedTasks(action_dict={}, parms_keyframes_before_dict={}, parms_keyframes_after_dict={}):
        parm_count = 1
        
        report_str = ''

        action = action_dict['action']        
        
        if action == 'offset':
            offset = action_dict['offset']
            report_str += f'action: {action}\n\n'
            report_str += f'offset: {offset}\n\n\n'

        for i, (parm, keyframes) in enumerate(parms_keyframes_before_dict.items()):
            keyframe_count = 1
            report_str += (f'parm {parm_count}: {parm}\n\n')
            for k in range(0,len(keyframes)):
                report_str += f'keyframe {keyframe_count} before: {keyframes[k]}\n\n'
                report_str += f'keyframe {keyframe_count} after: {parms_keyframes_after_dict[parm][k]}\n\n'
                keyframe_count += 1
            parm_count += 1
            report_str += f'\n\n'
        hou.ui.displayMessage(report_str, title='Change log')


    # Code for performing the accepted changes after the user accepted them

    
    # Code for deleting a specified keyframe using frame
    def deleteKeyframe(frame):
        parm.deleteKeyframeAtFrame(frame)


    # Code for setting a new keyframe using a keyframe definition from a dict
    def setKeyframeFromDict(keyframe_dict):

        # a new keyframe from template
        keyframe_out = hou.Keyframe()

        # configure the new keyframe from the dict
        keyframe_out.fromJSON(keyframe_dict)

        # set a new keyframe
        parm.setKeyframe(keyframe_out)

        
    # main:

    # get user inputs
    inputs_validated, selected_nodes = getSelectedNodes()

    # get user action
    action_dict = {}
    action = getSelectedAction()
    if inputs_validated:
        action_dict['action'] = action
    
    if inputs_validated and action == 'offset':
        inputs_validated, offset = getUserOffset()
        action_dict['offset'] = offset 

        
    # get parms and keyframes
    if inputs_validated:
        parms_keyframes_before_dict = getParmsFromSelection(selected_nodes)

        
    # calculate intended changes into parms_keyframes_after_dict
    if inputs_validated:
        parms_keyframes_after_dict = getParmsKeyframesChanges(action_dict, parms_keyframes_before_dict)

    
    # present intended changes (ok, cancel)
    if inputs_validated:
        showIntendedTasks(action_dict, parms_keyframes_before_dict, parms_keyframes_after_dict)
        # todo: ok, cancel buttons
    

    # perform changes

commKeyframeManipulator()
Interested in character concepts, modeling, rigging, and animation. Related tool dev with Py and VEX.
User Avatar
Member
675 posts
Joined: Feb. 2017
Offline
Hey SWest,

Great idea to develop a tool to deal with keyframes! On my whishlist would also be a button to clear channels with zero value change.

Cheers
CYTE
User Avatar
Member
308 posts
Joined: Oct. 2016
Offline
CYTE
Hey SWest,

Great idea to develop a tool to deal with keyframes! On my whishlist would also be a button to clear channels with zero value change.

Cheers
CYTE

Hi there. Yes indeed. There exist several methods for things like deleting or setting keyframes in the Houdini API (HOM) already. However, reusing them for tedious repetitive tasks could probably be made quicker and easier, for example applying something on all parms in one go. Most of the code is just for adding some error handling and user friendliness.


def commKeyframeManipulator():
    """Code for manipulating keyframed parms of a selected node. 
    It utilizes the Houdini Object Model (HOM) included in SideFX Houdini. 
    It is made for the Houdini community.
    Current version: 0.9.2 alpha.
    Authors: SWest (v0.9.2 alpha).
    License: https://creativecommons.org/licenses/by/4.0"""
    

    # Code for some initial variables
    actions = ('offset','clear','cancel')
    inputs_valid = True

    
    def getNodes():
        """A function for getting all the user's desired nodes to adjust."""

        selected_nodes = hou.selectedNodes()
        inputs_valid = False  
        if selected_nodes == ():
            message = 'Please select a node with keyframes and try again. '
            hou.ui.displayMessage(message)
        else:
            inputs_valid = True 
        return inputs_valid, selected_nodes


    def getAction(actions):
        """A function used to present the user with a set of tools related
        to manipulating keyframes. The user's choice is returned."""

        choice = hou.ui.displayMessage('Please choose desired action:', 
                                       buttons=actions, 
                                       default_choice=0, close_choice=0)

        inputs_valid = True
        action = actions[choice]
        if action == 'cancel':
            inputs_valid = False

        return inputs_valid, action
    
    
    def getRange():
        """A function used to let the user set a selection range. 
        It accepts text input such as 0-99 and returns a tuple like (0,99).
        Both values are inclusive so that they will be used."""

        range_tuple = ()
        choice = 0
        message = """Please type a frame range. \n\n
        Example: 0-99"""
        choice, range_str = hou.ui.readInput(message, buttons=('OK','Cancel'), 
                                             severity=hou.severityType.Message, 
                                             default_choice=0, close_choice=1, 
                                             help=None, title=None, 
                                             initial_contents='0-10000') 
        inputs_valid = True
        try:
            range_list = range_str.split('-')
            range_tuple = (float(range_list[0]),float(range_list[1]))
        except:
            message='There was an error with the input range text. '
            hou.ui.displayMessage(message)
            inputs_valid = False
        if choice == 1: # User clicked Cancel
            inputs_valid = False

        return inputs_valid, range_tuple

        
    def getOffset():
        """A function used getting a users choice and validating inputs 
        for intended data types."""

        message = """Please type a positive or negative number for frame offset.\n\n 
        Example: -10 or 15.5"""
        offset = hou.ui.readInput(message, buttons=('OK',), 
                                  severity=hou.severityType.Message, 
                                  default_choice=0, close_choice=-1, 
                                  help=None, title='offset', 
                                  initial_contents=None) 
        inputs_valid = True
        try:
            offset = float(offset[1])
        except:
            message='There was an error with the input number. '
            hou.ui.displayMessage(message)
            inputs_valid = False
            
        return inputs_valid, offset


    def getParmsFromRange(action_dict, selected_nodes):
        """A function used for getting all parms with keyframes from the 
        user's selection. It will return a dict with the parm path and 
        keyframes as a list using json"""
        parms_input_dict = {}
        range_start, range_end = action_dict['selection_range'] 

        for node in selected_nodes:
            for parm in node.allParms():
                parm_is_scoped = parm.isScoped()
                parm_keyframes_length = 0
                try:
                    parm_keyframes = parm.keyframes()
                except:
                    break
                else:
                    parm_keyframes_length=len(parm_keyframes)

                if(parm_keyframes_length > 0 and parm_is_scoped):
                    keyframes_input = []
                    for i in range(0,parm_keyframes_length):
                        keyframe_dict=parm.keyframes()[i].asJSON(save_keys_in_frames=True)
                        # check if the frame is in the selction range
                        frame_in=keyframe_dict['frame']
                        if ( frame_in >= range_start) and (frame_in <= range_end):
                            keyframes_input.append(keyframe_dict.copy())

                    if len(keyframes_input) > 0:
                        parms_input_dict[parm.path()] = keyframes_input

        return parms_input_dict
        
        
    def getChanges(action_dict, parms_input_dict):
        """A function used to calculate the intended changes to keyframes.
        It support different actions (tools). """

        parms_output_dict = {}
        keyframes_output_list = []
        action = action_dict['action']
        if action == 'offset':
            offset = action_dict['offset']

        range_start, range_end = action_dict['selection_range']

        for parm, keyframes in parms_input_dict.items():
            keyframes_output_list = []
            for keyframe in keyframes:
                keyframe_dict = keyframe.copy()
                frame_in=keyframe_dict['frame']

                if ( frame_in >= range_start) and (frame_in <= range_end):

                    if action == 'offset':
                        frame_out=frame_in+offset
                        keyframe_dict['frame'] = frame_out
                        keyframes_output_list.append(keyframe_dict.copy())

                    if action == 'clear':
                        if keyframe_dict not in keyframes_output_list:
                            keyframes_output_list.append(keyframe_dict.copy())
 
            parms_output_dict[parm] = keyframes_output_list
 
        return parms_output_dict       


    def showIntentions(action_dict={}, parms_input_dict={}, parms_output_dict={}):
        """A function used to show the proposed changes based on the user input. 
        The user may proceed with OK or cancel the action. """
 
        action = action_dict['action']   
        range_start, range_end = action_dict['selection_range']
        inputs_valid = True

        report_str = ''
        report_str += f'action: {action}\n\n'
        report_str += f'selection range: {range_start}-{range_end}\n\n'

        if len(parms_input_dict.items()) > 0:
            is_keyframes = True
        else: 
            is_keyframes = False

        # distinguish report type
        if is_keyframes:
            parm_count = 1


            if action == 'offset':
                offset = action_dict['offset']
                report_str += f'offset: {offset}\n\n'
                report_str += f'Parms and keyframes: \n\n\n'
                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n\n'
                        report_str += f'from: {keyframes[k]}\n\n'
                        report_str += f'to: {parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'


            if action == 'clear':
                report_str += f'Keyframes to be deleted: \n\n\n'

                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    #if len(keyframes) > 1:
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n'
                        report_str += f'{parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False


        # no keyframes
        else: 
            report_str += f'No keyframes are found in the selection. \n\n\n'
            inputs_valid = False
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False

        return inputs_valid


    def setSingleKeyframe(parm_str, keyframe_dict):
        """A function used to set a new keyframe using a definition from a dict."""

        # a new keyframe from template
        keyframe_out = hou.Keyframe()

        # configure the new keyframe from the dict
        keyframe_out.fromJSON(keyframe_dict)

        # set a new keyframe
        hou.parm(parm_str).setKeyframe(keyframe_out)


    def deleteKeyframes(input_dict):
        """A function used to delete keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                frame_out = keyframe_dict['frame']
                hou.parm(parm_str).deleteKeyframeAtFrame(frame_out)


    def setKeyframes(input_dict):
        """A function used to set keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                setSingleKeyframe(parm_str, keyframe_dict)


    def makeChanges(action_dict={}, parms_input_dict={}, parms_output_dict={}):
        """A function used to make actual changes to keyframes specified 
        in the input dictionary. All inputs should already have been validated. 
        The proposed changes should be accepted by the user before this stage."""

        action = action_dict['action']   
        range_start, range_end = action_dict['selection_range']

        if action == 'offset':
            offset = action_dict['offset']
            deleteKeyframes(parms_input_dict)
            setKeyframes(parms_output_dict)

        if action == 'clear':
            deleteKeyframes(parms_output_dict)

        
    # main:


    # get user inputs
    inputs_valid, selected_nodes = getNodes()


    # get user action
    action_dict = {}
    inputs_valid, action = getAction(actions)
    if inputs_valid:
        action_dict['action'] = action


    # get user selection range
    if inputs_valid:
        inputs_valid, selection_range = getRange()
        action_dict['selection_range'] = selection_range
    
    
    # get various inputs based on selected action
    if inputs_valid and action == 'offset':
        inputs_valid, offset = getOffset()
        action_dict['offset'] = offset
    elif inputs_valid and action == 'clear':
        pass
    else:
        inputs_valid = False

        
    # get parms and keyframes from user selection
    if inputs_valid:
        parms_input_dict = getParmsFromRange(action_dict, selected_nodes)

        
    # calculate intended changes into parms_output_dict
    if inputs_valid:
        parms_output_dict = getChanges(action_dict, parms_input_dict)

    
    # present intended changes
    if inputs_valid:
        inputs_valid = showIntentions(action_dict, parms_input_dict, 
                                      parms_output_dict)
    

    # perform changes
    if inputs_valid:
        makeChanges(action_dict, parms_input_dict, parms_output_dict)


commKeyframeManipulator()

Edit: Improving the readability and a minor bug fix
Edited by SWest - April 2, 2023 14:14:41
Interested in character concepts, modeling, rigging, and animation. Related tool dev with Py and VEX.
User Avatar
Member
308 posts
Joined: Oct. 2016
Offline
Changes:

1. Investigating a "refit" method and another type of UI window. Here only the user interaction is tested. The feature itself is not yet implemented.

def commKeyframeManipulator():
    """Code for manipulating keyframed parms of a selected node. 
    It utilizes the Houdini Object Model (HOM) included in SideFX Houdini. 
    It is made for the Houdini community.
    Current version: 0.9.3 alpha.
    Authors: SWest (v0.9.3 alpha).
    License: https://creativecommons.org/licenses/by/4.0"""
    

    # Code for some initial variables
    actions = ('offset','clear','refit','cancel')
    inputs_valid = True

    
    def getNodes():
        """A function for getting all the user's desired nodes to adjust."""

        selected_nodes = hou.selectedNodes()
        inputs_valid = False  
        if selected_nodes == ():
            message = 'Please select a node with keyframes and try again. '
            hou.ui.displayMessage(message)
        else:
            inputs_valid = True 
        return inputs_valid, selected_nodes


    def getAction(actions):
        """A function used to present the user with a set of tools related
        to manipulating keyframes. The user's choice is returned."""

        choice = hou.ui.displayMessage('Please choose desired action:', 
                                       buttons=actions, 
                                       default_choice=0, close_choice=0)

        inputs_valid = True
        action = actions[choice]
        if action == 'cancel':
            inputs_valid = False

        return inputs_valid, action
    
    
    def getRange():
        """A function used to let the user set a selection range. 
        It accepts text input such as 0-99 and returns a tuple like (0,99).
        Both values are inclusive so that they will be used."""

        range_tuple = ()
        choice = 0
        message = """Please type a frame range. \n\n
        Example: 0-99"""
        choice, range_str = hou.ui.readInput(message, buttons=('OK','Cancel'), 
                                             severity=hou.severityType.Message, 
                                             default_choice=0, close_choice=1, 
                                             help=None, title=None, 
                                             initial_contents='0-10000') 
        inputs_valid = True
        try:
            range_list = range_str.split('-')
            range_tuple = (float(range_list[0]),float(range_list[1]))
        except:
            message='There was an error with the input range text. '
            hou.ui.displayMessage(message)
            inputs_valid = False
        if choice == 1: # User clicked Cancel
            inputs_valid = False

        return inputs_valid, range_tuple

        
    def getOffset():
        """A function used getting a users choice and validating inputs 
        for intended data types."""

        message = """Please type a positive or negative number for frame offset.\n\n 
        Example: -10 or 15.5"""
        offset = hou.ui.readInput(message, buttons=('OK',), 
                                  severity=hou.severityType.Message, 
                                  default_choice=0, close_choice=-1, 
                                  help=None, title='offset', 
                                  initial_contents=None) 
        inputs_valid = True
        try:
            offset = float(offset[1])
        except:
            message='There was an error with the input number. '
            hou.ui.displayMessage(message)
            inputs_valid = False
            
        return inputs_valid, offset


    def getParmsFromRange(action_dict, selected_nodes):
        """A function used for getting all parms with keyframes from the 
        user's selection. It will return a dict with the parm path and 
        keyframes as a list using json"""
        parms_input_dict = {}
        range_start, range_end = action_dict['selection_range'] 

        for node in selected_nodes:
            for parm in node.allParms():
                parm_is_scoped = parm.isScoped()
                parm_keyframes_length = 0
                try:
                    parm_keyframes = parm.keyframes()
                except:
                    break
                else:
                    parm_keyframes_length=len(parm_keyframes)

                if(parm_keyframes_length > 0 and parm_is_scoped):
                    keyframes_input = []
                    for i in range(0,parm_keyframes_length):
                        keyframe_dict=parm.keyframes()[i].asJSON(save_keys_in_frames=True)
                        # check if the frame is in the selction range
                        frame_in=keyframe_dict['frame']
                        if ( frame_in >= range_start) and (frame_in <= range_end):
                            keyframes_input.append(keyframe_dict.copy())

                    if len(keyframes_input) > 0:
                        parms_input_dict[parm.path()] = keyframes_input

        return parms_input_dict


    def getRefit(action_dict):
        """A function used to get the necessary inputs for the refit action. 
        Todo: extract snippets to reusable functions. """

        title = 'Refit settings'

        message = 'Refit settings'
        
        input_labels = ('refit','refit_tol','refit_preserve_extrema',
                        'refit_bezier','resample','resample_rate',
                        'resample_tol','range','range_start','range_end',
                        'bake_chop')
        
        initial_contents = ('True','1.0','False',
                        'True','False','1.0',
                        '1.0','True','1','100',
                        '1')

        help = """
        refit
        True: cubic refitting
        False: only resampling or range but no refitting        
        refit_tol: refit tolerance in absolute value
        refit_preserve_extrema: preserves keys that are local minima or maxima
        
        refit_bezier
        True: new keyframes use bezier()
        False: new keyframes use cubic()
        
        resample
        True: Resampling is done before refitting
        False: No resampling        
        resample_rate: 1.0 adds a keyframe for each frame. 
        resample_tol: 1.0 meaning no resampling for subframes. 
        
        range
        True: use range_start and range_end
        False: use first and last keyframe
        range_start: Start frame
        range_end: End frame

        bake_chop: an enumeration value
        """

        buttons = ('OK','Cancel')
        
        severity = hou.severityType.Message
                
        
        choice,inputs = hou.ui.readMultiInput(message=message, input_labels=input_labels, password_input_indices=(), 
                              buttons=buttons, severity=severity, 
                              default_choice=0, close_choice=-1, help=help, 
                              title=title, initial_contents=initial_contents)

        print(f'choice: {choice} inputs: {inputs}')

        inputs_valid = False # temp
        if not choice == 0:
            inputs_valid = False
            # todo: 
            # add input_labels to action_dict keys
            # take the output string and add this to the action_dict

        choice = hou.ui.displayMessage('refit is not yet fully implemented', title='Notification', 
                                        buttons=('OK',))

        return inputs_valid, action_dict
        
        
    def getChanges(action_dict, parms_input_dict):
        """A function used to calculate the intended changes to keyframes.
        It support different actions (tools). """

        parms_output_dict = {}
        keyframes_output_list = []
        action = action_dict['action']
        if action == 'offset':
            offset = action_dict['offset']

        range_start, range_end = action_dict['selection_range']

        for parm, keyframes in parms_input_dict.items():
            keyframes_output_list = []
            for keyframe in keyframes:
                keyframe_dict = keyframe.copy()
                frame_in=keyframe_dict['frame']

                if ( frame_in >= range_start) and (frame_in <= range_end):

                    if action == 'offset':
                        frame_out=frame_in+offset
                        keyframe_dict['frame'] = frame_out
                        keyframes_output_list.append(keyframe_dict.copy())

                    if action == 'clear':
                        if keyframe_dict not in keyframes_output_list:
                            keyframes_output_list.append(keyframe_dict.copy())
 
            parms_output_dict[parm] = keyframes_output_list
 
        return parms_output_dict       


    def showIntentions(action_dict={}, parms_input_dict={}, parms_output_dict={}):
        """A function used to show the proposed changes based on the user input. 
        The user may proceed with OK or cancel the action. """
 
        action = action_dict['action']   
        range_start, range_end = action_dict['selection_range']
        inputs_valid = True

        report_str = ''
        report_str += f'action: {action}\n\n'
        report_str += f'selection range: {range_start}-{range_end}\n\n'

        if len(parms_input_dict.items()) > 0:
            is_keyframes = True
        else: 
            is_keyframes = False

        # distinguish report type
        if is_keyframes:
            parm_count = 1


            if action == 'offset':
                offset = action_dict['offset']
                report_str += f'offset: {offset}\n\n'
                report_str += f'Parms and keyframes: \n\n\n'
                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n\n'
                        report_str += f'from: {keyframes[k]}\n\n'
                        report_str += f'to: {parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'


            if action == 'clear':
                report_str += f'Keyframes to be deleted: \n\n\n'

                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    #if len(keyframes) > 1:
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n'
                        report_str += f'{parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False


        # no keyframes
        else: 
            report_str += f'No keyframes are found in the selection. \n\n\n'
            inputs_valid = False
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False

        return inputs_valid


    def setSingleKeyframe(parm_str, keyframe_dict):
        """A function used to set a new keyframe using a definition from a dict."""

        # a new keyframe from template
        keyframe_out = hou.Keyframe()

        # configure the new keyframe from the dict
        keyframe_out.fromJSON(keyframe_dict)

        # set a new keyframe
        hou.parm(parm_str).setKeyframe(keyframe_out)


    def deleteKeyframes(input_dict):
        """A function used to delete keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                frame_out = keyframe_dict['frame']
                hou.parm(parm_str).deleteKeyframeAtFrame(frame_out)


    def setKeyframes(input_dict):
        """A function used to set keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                setSingleKeyframe(parm_str, keyframe_dict)


    def makeChanges(action_dict, parms_input_dict, parms_output_dict):
        """A function used to make actual changes to keyframes specified 
        in the input dictionary. All inputs should already have been validated. 
        The proposed changes should be accepted by the user before this stage."""

        action = action_dict['action']   
        range_start, range_end = action_dict['selection_range']

        if action == 'offset':
            offset = action_dict['offset']
            deleteKeyframes(parms_input_dict)
            setKeyframes(parms_output_dict)

        if action == 'clear':
            deleteKeyframes(parms_output_dict)

        if action == 'refit':
            pass
            #deleteKeyframes(parms_input_dict)
            #setKeyframes(parms_output_dict)
        
    # main:


    # get user inputs
    inputs_valid, selected_nodes = getNodes()


    # get user action
    action_dict = {}
    inputs_valid, action = getAction(actions)
    if inputs_valid:
        action_dict['action'] = action
  
    
    # get various inputs based on selected action
    if inputs_valid and action == 'offset':
        inputs_valid, offset = getOffset()
        action_dict['offset'] = offset
    elif inputs_valid and action == 'clear':
        pass
    elif inputs_valid and action == 'refit':
        inputs_valid, action_dict = getRefit(action_dict)
    else:
        inputs_valid = False


    # get user selection range
    if inputs_valid:
        inputs_valid, selection_range = getRange()
        action_dict['selection_range'] = selection_range


    # get parms and keyframes from user selection
    if inputs_valid:
        parms_input_dict = getParmsFromRange(action_dict, selected_nodes)

        
    # calculate intended changes into parms_output_dict
    if inputs_valid:
        parms_output_dict = getChanges(action_dict, parms_input_dict)

    
    # present intended changes
    if inputs_valid:
        inputs_valid = showIntentions(action_dict, parms_input_dict, 
                                      parms_output_dict)
    

    # perform changes
    if inputs_valid:
        makeChanges(action_dict, parms_input_dict, parms_output_dict)
    


commKeyframeManipulator()
Edited by SWest - April 13, 2023 07:22:47
Interested in character concepts, modeling, rigging, and animation. Related tool dev with Py and VEX.
User Avatar
Member
308 posts
Joined: Oct. 2016
Offline
Changes:

1. A small update to the UI so fewer input windows are used.

2. The "clear" action will now reset scoped parms to default. To avoid unexpected changes to parms there must be a method to identify which parms are used for animation. For that hou.parm(parm_path).isScoped() can be used. Then it is possible to easily control what parms or set of parms are cleared.

Extra: If you merely want to clear channels without limiting the range you can simply use shift+ctrl+LMB and ctrl+MMB on a group label for parms. At least one group is needed because the tool need to know what parms you are dealing with. A video is added to show this.

def commKeyframeManipulator():
    """Code for manipulating keyframed parms of one or many selected node(s). 
    Keyframes are expected to have been promoted to the selected node. 
    That is why this function only look for keyframes on the selected node(s) parms. 
    It utilizes the Houdini Object Model (HOM) included in SideFX Houdini. 
    It is made for the Houdini community.
    Current version: 0.9.5 alpha.
    Authors: SWest (v0.9.5 alpha).
    License: https://creativecommons.org/licenses/by/4.0"""
    

    # Code for some initial variables
    actions = ('offset','clear','refit','cancel')
    inputs_valid = True

    
    def getNodes():
        """A function for getting all the user's desired nodes to adjust."""

        selected_nodes = hou.selectedNodes()
        inputs_valid = False  
        if selected_nodes == ():
            message = 'Please select a node with keyframes and try again. '
            hou.ui.displayMessage(message)
        else:
            inputs_valid = True 
        return inputs_valid, selected_nodes


    def getAction(actions):
        """A function used to present the user with a set of tools related
        to manipulating keyframes. The user's choice is returned."""

        choice = hou.ui.displayMessage('Please choose desired action:', 
                                       buttons=actions, 
                                       default_choice=0, close_choice=0)

        inputs_valid = True
        action = actions[choice]
        if action == 'cancel':
            inputs_valid = False

        return inputs_valid, action

        
    def getOffset(action_dict):
        """A function used to get the necessary inputs for the offset action. 
        This include offset value and range. 
        Todo: extract snippets to reusable functions. """

        
        title = 'Offset'

        message = 'Offset settings'
        
        temp_dict = {
        'offset': '1.0',
        'range_start': '1',
        'range_end': '10000'
        }
        
        input_labels = ()
        for k,v in temp_dict.items():
            input_labels += (k,)
        
        initial_contents = ()
        for v in temp_dict.values():
            initial_contents += (v,)

        help = """
        Help for offset:
        
        offset
        A positive or negative number for frame offset.
        Example: -10 or 15.5 (float)
                
        range
        range_start: Start frame (float)
        range_end: End frame (float)

        """

        buttons = ('OK','Cancel')
        
        severity = hou.severityType.Message
        
        choice, user_contents = hou.ui.readMultiInput(message=message, input_labels=input_labels, password_input_indices=(), 
                              buttons=buttons, severity=severity, 
                              default_choice=0, close_choice=-1, help=help, 
                              title=title, initial_contents=initial_contents)
            
        inputs_valid = True

        try:
            action_dict['offset'] = float(user_contents[0])
        except:
            inputs_valid = False
            message = 'The offset value is not valid. '

        try:
            action_dict['range_start'] = float(user_contents[1])
        except:
            inputs_valid = False
            message = 'The range start value is not valid. '

        try:
            action_dict['range_end'] = float(user_contents[2])
            message = 'The range end value is not valid. '
        except:
            inputs_valid = False

        if not inputs_valid:
            temp = hou.ui.displayMessage('Input error: '+message, title='Notification', buttons=('OK',))
            
        if not choice == 0:
            inputs_valid = False

        # print the settings (debug)
        # for k,v in action_dict.items():
        #    print(f'{k} {v} {type(v)}')

        return inputs_valid, action_dict



    def getClear(action_dict):
        """A function used to get the necessary inputs for the clear action. 
        This include range. 
        Todo: extract snippets to reusable functions. """

        
        title = 'Clear'

        message = 'Settings for the Clear action'
        
        temp_dict = {
        'range_start': '1',
        'range_end': '10000'
        }
        
        input_labels = ()
        for k,v in temp_dict.items():
            input_labels += (k,)
        
        initial_contents = ()
        for v in temp_dict.values():
            initial_contents += (v,)

        help = """
        Help for Clear:
        
        Scoped parms or channels will be reset to default.
        Parms or channels can be categorized with tabs (folders) in the properties UI.
        To set parms as scoped shift+MMB on a UI folder or parm label.
        To disable a parm as scoped shift+ctrl+MMB on a tab or a parm label. 
                        
        range
        range_start: Start frame (float)
        range_end: End frame (float)

        """

        buttons = ('OK','Cancel')
        
        severity = hou.severityType.Message
        
        choice, user_contents = hou.ui.readMultiInput(message=message, input_labels=input_labels, password_input_indices=(), 
                              buttons=buttons, severity=severity, 
                              default_choice=0, close_choice=-1, help=help, 
                              title=title, initial_contents=initial_contents)
            
        inputs_valid = True

        try:
            action_dict['range_start'] = float(user_contents[0])
        except:
            inputs_valid = False
            message = 'The range start value is not valid. '

        try:
            action_dict['range_end'] = float(user_contents[1])
        except:
            inputs_valid = False
            message = 'The range end value is not valid. '

        if not inputs_valid:
            temp = hou.ui.displayMessage('Input error: '+message, title='Notification', buttons=('OK',))
            
        if not choice == 0:
            inputs_valid = False

        return inputs_valid, action_dict

        
    def getRefit(action_dict):
        """A function used to get the necessary inputs for the refit action. 
        Todo: extract snippets to reusable functions. """

        
        title = 'Refit'

        message = 'Refit settings'
        
        refit_dict = {
        'refit': 'True',
        'refit_tol': '1.0',
        'refit_preserve_extrema': 'False',
        'refit_bezier': 'True',
        'resample': 'False',
        'resample_rate': '1.0',
        'resample_tol': '1.0',
        'range': 'True',
        'range_start': '1',
        'range_end': '100',
        'bake_chop': '1'
        }
        
        input_labels = ()
        for k,v in refit_dict.items():
            input_labels += (k,)
        
        initial_contents = ()
        for v in refit_dict.values():
            initial_contents += (str(v),)

        help = """
        Help for refit:
        
        refit
        True: cubic refitting
        False: only resampling or range but no refitting        
        refit_tol: refit tolerance in absolute value
        refit_preserve_extrema: preserves keys that are local minima or maxima
        
        refit_bezier
        True: new keyframes use bezier()
        False: new keyframes use cubic()
        
        resample
        True: Resampling is done before refitting
        False: No resampling        
        resample_rate: 1.0 adds a keyframe for each frame. 
        resample_tol: 1.0 meaning no resampling for subframes. 
        
        range
        True: use range_start and range_end
        False: use first and last keyframe
        range_start: Start frame
        range_end: End frame

        bake_chop: an enumeration value
        """

        buttons = ('OK','Cancel')
        
        severity = hou.severityType.Message
        
        choice, user_contents = hou.ui.readMultiInput(message=message, input_labels=input_labels, password_input_indices=(), 
                              buttons=buttons, severity=severity, 
                              default_choice=0, close_choice=-1, help=help, 
                              title=title, initial_contents=initial_contents)
            
        # for i, (k,v) in enumerate(refit_dict.items()):
        #    refit_dict[k] = user_contents[i]

        # validate inputs
        inputs_valid = True
        # something is wrong with the following
        try:
            action_dict['refit'] = bool(user_contents[0])
            action_dict['refit_tol'] = float(user_contents[1])
            action_dict['refit_preserve_extrema'] = bool(user_contents[2])
            action_dict['refit_bezier'] = bool(user_contents[3])
            action_dict['resample'] = bool(user_contents[4])
            action_dict['resample_rate'] = float(user_contents[5])
            action_dict['resample_tol'] = float(user_contents[6])
            action_dict['range'] = bool(user_contents[7])
            action_dict['range_start'] = float(user_contents[8])
            action_dict['range_end'] = float(user_contents[9])
            action_dict['bake_chop'] = float(user_contents[10])
        except:
            inputs_valid = False
        else:
            for k,v in action_dict.items():
                print(f'{k} {v}')
            # action_dict.update(refit_dict)

        if not choice == 0:
            inputs_valid = False

        temp = hou.ui.displayMessage('refit is not yet fully implemented', title='Notification', 
                                        buttons=('OK',))

        return inputs_valid, action_dict
        
        
    def getParmsFromRange(action_dict, selected_nodes):
        """A function used for getting all parms with keyframes from the 
        user's selection. It will return a dict with the parm path and 
        keyframes as a list using json"""

        parms_input_dict = {}
        range_start = action_dict['range_start']
        range_end = action_dict['range_end']

        for node in selected_nodes:
            for parm in node.allParms():
                parm_is_scoped = parm.isScoped()
                parm_keyframes_length = 0
                try:
                    parm_keyframes = parm.keyframes()
                except:
                    break
                else:
                    parm_keyframes_length=len(parm_keyframes)

                if(parm_keyframes_length > 0 and parm_is_scoped):
                    keyframes_input = []
                    for i in range(0,parm_keyframes_length):
                        keyframe_dict=parm.keyframes()[i].asJSON(save_keys_in_frames=True)
                        frame_in=keyframe_dict['frame']
                        if ( frame_in >= range_start) and (frame_in <= range_end):
                            keyframes_input.append(keyframe_dict.copy())

                    if len(keyframes_input) > 0:
                        parms_input_dict[parm.path()] = keyframes_input

        return parms_input_dict


    def getChanges(action_dict, parms_input_dict):
        """A function used to calculate the intended changes to keyframes.
        It support different actions (tools). """

        parms_output_dict = {}
        keyframes_output_list = []
        action = action_dict['action']
        if action == 'offset':
            offset = action_dict['offset']

        range_start = action_dict['range_start']
        range_end = action_dict['range_end']

        for parm, keyframes in parms_input_dict.items():
            keyframes_output_list = []
            for keyframe in keyframes:
                keyframe_dict = keyframe.copy()
                frame_in=keyframe_dict['frame']

                if ( frame_in >= range_start) and (frame_in <= range_end):

                    if action == 'offset':
                        frame_out=frame_in+offset
                        keyframe_dict['frame'] = frame_out
                        keyframes_output_list.append(keyframe_dict.copy())

                    if action == 'clear':
                        if keyframe_dict not in keyframes_output_list:
                            keyframes_output_list.append(keyframe_dict.copy())
 
            parms_output_dict[parm] = keyframes_output_list
 
        return parms_output_dict       


    def showIntentions(action_dict={}, parms_input_dict={}, parms_output_dict={}):
        """A function used to show the proposed changes based on the user input. 
        The user may proceed with OK or cancel the action. """
 
        action = action_dict['action']   
        range_start = action_dict['range_start']
        range_end = action_dict['range_end']
        inputs_valid = True

        report_str = ''
        report_str += f'action: {action}\n\n'
        report_str += f'selection range: {range_start}-{range_end}\n\n'

        if len(parms_input_dict.items()) > 0:
            is_keyframes = True
        else: 
            is_keyframes = False

        # distinguish report type
        if is_keyframes:
            parm_count = 1


            if action == 'offset':
                offset = action_dict['offset']
                report_str += f'offset: {offset}\n\n'
                report_str += f'Parms and keyframes: \n\n\n'
                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n\n'
                        report_str += f'from: {keyframes[k]}\n\n'
                        report_str += f'to: {parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'


            if action == 'clear':
                report_str += f'Keyframes to be deleted: \n\n\n'

                for i, (parm, keyframes) in enumerate(parms_input_dict.items()):
                    keyframe_count = 1
                    report_str += (f'parm {parm_count}: {parm}\n\n')
                    #if len(keyframes) > 1:
                    for k in range(0,len(keyframes)):
                        report_str += f'keyframe {keyframe_count}\n'
                        report_str += f'{parms_output_dict[parm][k]}\n\n'
                        keyframe_count += 1
                    parm_count += 1
                    report_str += f'\n\n'
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False


        # no keyframes
        else: 
            report_str += f'No keyframes are found in the selection. \n\n\n'
            inputs_valid = False
            choice = hou.ui.displayMessage(report_str, title='Proposed changes', 
                                           buttons=('OK','Cancel'))
            if choice == 1:
                inputs_valid = False

        return inputs_valid


    def setSingleKeyframe(parm_str, keyframe_dict):
        """A function used to set a new keyframe using a definition from a dict."""

        # a new keyframe from template
        keyframe_out = hou.Keyframe()

        # configure the new keyframe from the dict
        keyframe_out.fromJSON(keyframe_dict)

        # set a new keyframe
        hou.parm(parm_str).setKeyframe(keyframe_out)


    def deleteKeyframes(input_dict):
        """A function used to delete keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                frame_out = keyframe_dict['frame']
                hou.parm(parm_str).deleteKeyframeAtFrame(frame_out)


    def setKeyframes(input_dict):
        """A function used to set keyframes from a dict."""

        for parm_str, keyframes in input_dict.items():
            for keyframe_dict in keyframes:
                setSingleKeyframe(parm_str, keyframe_dict)
                
                
    def setScopedParmsToDefault(selected_nodes):
        """A function used to set parms to default.
        It is limited to scoped parms.
        It must be so otherwise all parms are reset to default and that 
        might lead to undesired results."""
        for node in selected_nodes:
            for parm in node.allParms():
                parm_is_scoped = parm.isScoped()
                if parm.isScoped():
                    parm.revertToDefaults()


    def makeChanges(action_dict, parms_input_dict, parms_output_dict):
        """A function used to make actual changes to keyframes specified 
        in the input dictionary. All inputs should already have been validated. 
        The proposed changes should be accepted by the user before this stage."""

        action = action_dict['action']   
        range_start = action_dict['range_start']
        range_end = action_dict['range_end']

        if action == 'offset':
            offset = action_dict['offset']
            deleteKeyframes(parms_input_dict)
            setKeyframes(parms_output_dict)

        if action == 'clear':
            deleteKeyframes(parms_output_dict)

        if action == 'refit':
            pass
            #deleteKeyframes(parms_input_dict)
            #setKeyframes(parms_output_dict)
        

    # main:

    # get user inputs start
    inputs_valid, selected_nodes = getNodes()
    action_dict = {}


    # get user action
    if inputs_valid:
        inputs_valid, action = getAction(actions)
    if inputs_valid:
        action_dict['action'] = action
  
    
    # get various inputs based on selected action
    if inputs_valid:
        if action == 'offset':
            inputs_valid, action_dict = getOffset(action_dict)
        elif action == 'clear':
            inputs_valid, action_dict = getClear(action_dict)
            setScopedParmsToDefault(selected_nodes)
        elif action == 'refit':
            inputs_valid, action_dict = getRefit(action_dict)
        else:
            inputs_valid = False


    # get parms and keyframes from user selection
    if inputs_valid:
        parms_input_dict = getParmsFromRange(action_dict, selected_nodes)

        
    # calculate intended changes into parms_output_dict
    if inputs_valid:
        parms_output_dict = getChanges(action_dict, parms_input_dict)

    
    # present intended changes
    if inputs_valid:
        inputs_valid = showIntentions(action_dict, parms_input_dict, 
                                      parms_output_dict)
    

    # perform changes
    if inputs_valid:
        makeChanges(action_dict, parms_input_dict, parms_output_dict)



commKeyframeManipulator()
Edited by SWest - April 13, 2023 16:07:03

Attachments:
clear_keyframes_and_reset_parms_with_isscoped.mp4 (3.8 MB)
clear_channels_with_shift+ctrl+LMB_and_ctrl+MMB.mp4 (1.2 MB)

Interested in character concepts, modeling, rigging, and animation. Related tool dev with Py and VEX.
User Avatar
Member
251 posts
Joined: July 2013
Offline
Here is nice highlevel utility I wrote to control keyframes and handles, great for getting nice space- and temporally smooth easings.

The video is a bit older then current UI but the same principle remains





Part of my free and open toolkit: ttps://bitbucket.org/jcdeblok/jdb_houdinitoolkit/src/master/
Edited by Jonathan de Blok - May 23, 2023 09:52:43

Attachments:
Screenshot 2023-05-23 154823.jpg (23.3 KB)

More code, less clicks.
User Avatar
Member
251 posts
Joined: July 2013
Offline
And here is a small snippet to put in toolbar to shift all keyframes globally after the current time, great for adding some temporal space in complex animation setup without have to select all nodes.

Feel free to recycle bits if you find them useful!



import hou

#for debugging, can be omitted when used as shelf tool as that will populate the kwargs dict for us
if not "kwargs" in globals().keys():
kwargs={}
kwargs["shiftclick"]=False


res = hou.ui.readInput("Frames to shift keyframes after current frame", buttons=("OK", "Cancel"))

if res[0] == 0 and res[1].replace("-","").isnumeric():
currentFrame=hou.frame()
shift= int(res[1])
with hou.undos.group("Shift Keyframes"):
list(map(lambda animObj: hou.hscript( "chkeymv -r "+animObj.path()+"/* "+str(currentFrame)+" 20000 "+str(currentFrame+shift)+" "+str(20000+shift) ), list(filter(lambda node: len(list(filter(lambda parm: len(parm.keyframes())>1, node.parms() ))), hou.node("/" if kwargs["shiftclick"] else "/obj").allSubChildren() ))))


# this get all nodes from /obj or / (every context) depentingon shiftclick or not
# hou.node("/" if kwargs["shiftclick"] else "/obj").allSubChildren()

# This filter the nodes from the about to those that have parms that have more then 1 keyframe (parms with expression have a keyframe as well, and 1 keyframe parm are not animated since it will yield a contant value thus only use those with more then 1 parm)
# list(filter(lambda node: len(list(filter(lambda parm: len(parm.keyframes())>1, node.parms() )))

# The 'map' function then runs each node from the filtered list through a small lambda function that sets up and executes the hscript command 'chkeymv' with the correct settings
# map(lambda animObj: hou.hscript( "chkeymv -r "+animObj.path()+"/* "+str(currentFrame)+" 20000 "+str(currentFrame+shift)+" "+str(20000+shift) )

# The map function creates an iterator object that doesn't loop over all the items by itself so we force it to yeald by converting it into a list. The generated list itself isn't used
More code, less clicks.
  • Quick Links