Python Scripting in MotionBuilder

07 – Dealing with Keyframe Data: FBFCurve

Part Seven is an in-depth look at how to manipulate FCurves, including adding, removing, and modifying keyframes.

Watch on YouTube | Watch in a full window

Key Points


Code

Initialize a camera and get an FCurve for its Roll property

# Create a camera and look through it in the viewport
camera = FBCamera('Camera')
camera.Show = True
FBSystem().Renderer.CurrentCamera = camera
camera.Selected = True

# Animate the Roll property, thereby creating an FCurve
camera.Roll.SetAnimated(True)

# Keep a local reference to the roll FCurve
node = camera.Roll.GetAnimationNode()
fcurve = node.FCurve

Add some keys to an FCurve at specific frame numbers

fcurve.KeyAdd(FBTime(0, 0, 0, 0), 0.0)
fcurve.KeyAdd(FBTime(0, 0, 0, 100), 100.0)
fcurve.KeyAdd(FBTime(0, 0, 0, 50), 25.0)
fcurve.KeyAdd(FBTime(0, 0, 0, 50), 10.0)
# At this point, we have three new keys:
# 0.0 at frame 0, 10.0 at frame 50, and 100.0 at frame 100

Retrieve the FBFCurveKey object for a newly inserted key

index = fcurve.KeyAdd(FBTime(0, 0, 0, 75), 50.0)
key = fcurve.Keys[index]
print key # <FBFCurveKey object>

Iterate over an FCurve's keys, listing the data for each keyframe

for i in range(0, len(fcurve.Keys)):
    key = fcurve.Keys[i]
    print '[%d] Frame %3d: %.2f' % (
        i,
        key.Time.GetFrame(True),
        key.Value
    )
# "for key in fcurve.Keys" works equally well if you don't need the index

Generate a sine wave on an FCurve

import math

for i in range(0, 150):
    fcurve.KeyAdd(FBTime(0, 0, 0, i), math.sin(i * 0.1) * 10)

Delete a range of keys by index

# Specify a range. To delete a single key, use the same index for both values.
start = 50
stop = 100

# MotionBuilder will crash if the index range is out of bounds!
assert start >= 0 and start < len(fcurve.Keys)
assert stop >= 0 and stop < len(fcurve.Keys)

fcurve.KeyDeleteByIndexRange(start, stop)

Delete every other key in an FCurve

# Bad! Untouched keys have their indices shifted around as we iterate forward.
for i in range(0, len(fcurve.Keys), 2):
    fcurve.KeyDeleteByIndexRange(i, i)

# Works as intended!
for i in reversed(range(0, len(fcurve.Keys), 2)):
    fcurve.KeyDeleteByIndexRange(i, i)

Delete a range of keys by time

# Delete all keys between 2 seconds and the end of time.
# Since pInclusive is False, the key at the 2-second mark will be left intact.
fcurve.KeyDeleteByTimeRange(FBTime(0, 0, 2), FBTime.Infinity, False)

Delete all keys from an FCurve

fcurve.EditClear()
# Effectively equivalent to:
# fcurve.KeyDeleteByTimeRange(FBTime.MinusInfinity, FBTime.Infinity)

Serialize an FCurve into a list of dictionaries

def SerializeCurve(fcurve):
    '''
    Returns a list of dictionaries representing each of the keys in the given
    FCurve.
    '''
    keyDataList = []

    for key in fcurve.Keys:

        keyData = {
            'time': key.Time.Get(),
            'value': key.Value,
            'interpolation': int(key.Interpolation),
            'tangent-mode': int(key.TangentMode),
            'constant-mode': int(key.TangentConstantMode),
            'left-derivative': key.LeftDerivative,
            'right-derivative': key.RightDerivative,
            'left-weight': key.LeftTangentWeight,
            'right-weight': key.RightTangentWeight
        }

        keyDataList.append(keyData)

    return keyDataList

Populate an FCurve with data from SerializeCurve

def TangentWeightIsDefault(tangentWeight):
    '''
    Returns whether the given tangent weight is equal to the default value of
    1/3, taking floating-point precision into account.
    '''
    return tangentWeight > 0.3333 and tangentWeight < 0.3334

def DeserializeCurve(fcurve, keyDataList):
    '''
    Populates the given FCurve based on keyframe data listed in serialized
    form. Expects key data to be ordered by time. Any existing keys will be
    removed from the curve.
    '''
    # Ensure a blank slate
    fcurve.EditClear()

    # Loop 1: Add keys and set non-numeric properties
    for keyData in keyDataList:

        keyIndex = fcurve.KeyAdd(FBTime(keyData['time']), keyData['value'])
        key = fcurve.Keys[keyIndex]

        key.Interpolation = FBInterpolation.values[keyData['interpolation']]
        key.TangentMode = FBTangentMode.values[keyData['tangent-mode']]
        if key.TangentMode == FBTangentMode.kFBTangentModeTCB:
            key.TangentMode = FBTangentMode.kFBTangentModeBreak
        key.TangentConstantMode = \
          FBTangentConstantMode.values[keyData['constant-mode']]

    # Loop 2: With all keys in place, set tangent properties
    for i in range(0, len(keyDataList)):

        keyData = keyDataList[i]
        key = fcurve.Keys[i]

        key.LeftDerivative = keyData['left-derivative']
        key.RightDerivative = keyData['right-derivative']

        if not TangentWeightIsDefault(keyData['left-weight']):
            key.LeftTangentWeight = keyData['left-weight']
        if not TangentWeightIsDefault(keyData['right-weight']):
            key.RightTangentWeight = keyData['right-weight']

Notes & Errata

Additional Tangent Properties

There are a few less-important properties of FBFCurveKey that I didn't explicitly mention. In brief:


Tangent Weight

In the FCurve editor UI, there's a little button which enables custom tangent weight values for the selected key:

With the Wt button disabled, the tangent weights stay at their default value of 0.3333. With Wt enabled, the user can input a different value (between 0.0 and 1.0). Regardless of the value, the editor displays the tangents differently depending on whether the Wt button is enabled. When the user drags the tangent with custom weighting enabled, he's modifying the weight as well as the angle.

Unfortunately, there's no way to access this setting from Python. Initially, it's disabled, and as soon as either TangentWeight property is changed via the API, it's irrevocably enabled. That's the rationale for including that final step in the DeserializeCurve function: we don't modify the tangent weight if it was at the default value in the first place. Otherwise, the user would find that every single tangent handle affected the weight value as well as the angle.


Serializing Enumeration Values

You'll notice that when I save the values of each key's enumeration properties, I convert them to raw integers first. By using the values dictionary of each enumeration type, I can map those integers back into the correct value.

print FBInterpolation.values
# { 0: kFBInterpolationConstant,
#   1: kFBInterpolationLinear,
#   2: kFBInterpolationCubic }

# Convert from FBInterpolation to integer and back again
intValue = int(FBInterpolation.kFBInterpolationLinear)
interpolation = FBInterpolation.values[intValue]

print intValue, interpolation
# 1, kFBInterpolationLinear

Instead of using integers, you could just as well store the enumeration value names as strings. This would protect your data in case enumerations were added or shifted around in subsequent versions, and it would make the values a bit easier to deal with if you intended to use the data outside of MotionBuilder. It'd only take a bit more work:

# Convert from FBInterpolation to string and back again
strValue = str(FBInterpolation.kFBInterpolationLinear)
interpolation = getattr(FBInterpolation, strValue)

Similarly, I store the time of each keyframe as the internal FBTime value — that really big integer that only MotionBuilder knows how to interpret. To make things a bit more agnostic, you could use the floating-point GetSecondDouble() value instead. Using Get() just keeps things simple and avoids precision issues.


Serialization and File Formats

Around 6:53, I briefly demonstrate how to use the json module to write the serialized data for a curve to an ASCII file. Converting between simple objects and JSON files is quite painless:

import json
filepath = 'C:\\fcurve.json'

# Write a curve to a JSON file
with open(filepath, 'w') as fp:
    keyDataList = SerializeCurve(fcurve)
    fp.write(json.dumps(keyDataList, indent = 4))

# Read a curve from a JSON file
with open(filepath, 'r') as fp:
    keyDataList  = json.loads(fp.read())
    DeserializeCurve(fcurve, keyDataList)

JSON is a convenient format to use since it maps so cleanly to Python lists and dictionaries, but of course you could use any format you like. Another equally convenient option from the Python Standard Library is the pickle module:

import cPickle as pickle
filepath = 'C:\\fcurve_file'

# Pickle a curve to disk in binary format
with open(filepath, 'wb') as fp:
    keyDataList = SerializeCurve(fcurve)
    pickle.dump(keyDataList, fp, 2)

# Unpickle binary data back to the curve
with open(filepath, 'rb') as fp:
    keyDataList  = pickle.load(fp)
    DeserializeCurve(fcurve, keyDataList)

Corrections?

If you see any errors that you'd like to point out, feel free to email me at awforsythe@gmail.com.