Sybren A. Stüvel 9605cbde6f Fix #137041: Bugs with Slotted Actions and the Available Keying Set
Make the 'Available' keying set look at the F-Curves for the assigned
slot, instead of the backward-compatible API (which only sees the F-Curves
for the first slot).

Pull Request: https://projects.blender.org/blender/blender/pulls/137131
2025-04-08 11:10:46 +02:00

758 lines
27 KiB
Python

# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"bake_action",
"bake_action_objects",
"bake_action_iter",
"bake_action_objects_iter",
"BakeOptions",
)
import bpy
from bpy.types import Action, ActionSlot, ActionChannelbag
from dataclasses import dataclass
from collections.abc import (
Mapping,
Sequence,
)
from rna_prop_ui import (
rna_idprop_value_to_python,
)
FCurveKey = tuple[
# `fcurve.data_path`.
str,
# `fcurve.array_index`.
int,
]
# List of `[frame0, value0, frame1, value1, ...]` pairs.
ListKeyframes = list[float]
@dataclass
class BakeOptions:
only_selected: bool
"""Only bake selected bones."""
do_pose: bool
"""Bake pose channels"""
do_object: bool
"""Bake objects."""
do_visual_keying: bool
"""Use the final transformations for baking ('visual keying')."""
do_constraint_clear: bool
"""Remove constraints after baking."""
do_parents_clear: bool
"""Unparent after baking objects."""
do_clean: bool
"""Remove redundant keyframes after baking."""
do_location: bool
"""Bake location channels"""
do_rotation: bool
"""Bake rotation channels"""
do_scale: bool
"""Bake scale channels"""
do_bbone: bool
"""Bake b-bone channels"""
do_custom_props: bool
"""Bake custom properties."""
def action_get_channelbag_for_slot(action: Action | None, slot: ActionSlot | None) -> ActionChannelbag | None:
"""
Returns the first channelbag found for the slot.
In case there are multiple layers or strips they are iterated until a
channelbag for that slot is found. In case no matching channelbag is found, returns None.
"""
if not action or not slot:
# This is just for convenience so that you can call
# action_get_channelbag_for_slot(adt.action, adt.action_slot) and check
# the return value for None, without having to also check the action and
# the slot for None.
return None
for layer in action.layers:
for strip in layer.strips:
channelbag = strip.channelbag(slot)
if channelbag:
return channelbag
return None
def _ensure_channelbag_exists(action: Action, slot: ActionSlot) -> ActionChannelbag:
try:
layer = action.layers[0]
except IndexError:
layer = action.layers.new("Layer")
try:
strip = layer.strips[0]
except IndexError:
strip = layer.strips.new(type='KEYFRAME')
return strip.channelbag(slot, ensure=True)
def bake_action(
obj,
*,
action,
frames,
bake_options,
):
"""
:arg obj: Object to bake.
:type obj: :class:`bpy.types.Object`
:arg action: An action to bake the data into, or None for a new action
to be created.
:type action: :class:`bpy.types.Action` | None
:arg frames: Frames to bake.
:type frames: int
:arg bake_options: Options for baking.
:type bake_options: :class:`anim_utils.BakeOptions`
:return: Action or None.
:rtype: :class:`bpy.types.Action` | None
"""
if not (bake_options.do_pose or bake_options.do_object):
return None
action, = bake_action_objects(
[(obj, action)],
frames=frames,
bake_options=bake_options
)
return action
def bake_action_objects(
object_action_pairs,
*,
frames,
bake_options,
):
"""
A version of :func:`bake_action_objects_iter` that takes frames and returns the output.
:arg frames: Frames to bake.
:type frames: iterable of int
:arg bake_options: Options for baking.
:type bake_options: :class:`anim_utils.BakeOptions`
:return: A sequence of Action or None types (aligned with ``object_action_pairs``)
:rtype: Sequence[:class:`bpy.types.Action`]
"""
if not (bake_options.do_pose or bake_options.do_object):
return []
iter = bake_action_objects_iter(object_action_pairs, bake_options=bake_options)
iter.send(None)
for frame in frames:
iter.send(frame)
return iter.send(None)
def bake_action_objects_iter(
object_action_pairs,
bake_options,
):
"""
An coroutine that bakes actions for multiple objects.
:arg object_action_pairs: Sequence of object action tuples,
action is the destination for the baked data. When None a new action will be created.
:type object_action_pairs: Sequence of (:class:`bpy.types.Object`, :class:`bpy.types.Action`)
:arg bake_options: Options for baking.
:type bake_options: :class:`anim_utils.BakeOptions`
"""
scene = bpy.context.scene
frame_back = scene.frame_current
iter_all = tuple(
bake_action_iter(obj, action=action, bake_options=bake_options)
for (obj, action) in object_action_pairs
)
for iter in iter_all:
iter.send(None)
while True:
frame = yield None
if frame is None:
break
scene.frame_set(frame)
bpy.context.view_layer.update()
for iter in iter_all:
iter.send(frame)
scene.frame_set(frame_back)
yield tuple(iter.send(None) for iter in iter_all)
# XXX visual keying is actually always considered as True in this code...
def bake_action_iter(
obj,
*,
action,
bake_options,
):
"""
An coroutine that bakes action for a single object.
:arg obj: Object to bake.
:type obj: :class:`bpy.types.Object`
:arg action: An action to bake the data into, or None for a new action
to be created.
:type action: :class:`bpy.types.Action` | None
:arg bake_options: Boolean options of what to include into the action bake.
:type bake_options: :class:`anim_utils.BakeOptions`
:return: an action or None
:rtype: :class:`bpy.types.Action`
"""
# -------------------------------------------------------------------------
# Helper Functions and vars
# Note: BBONE_PROPS is a list so we can preserve the ordering
BBONE_PROPS = [
"bbone_curveinx", "bbone_curveoutx",
"bbone_curveinz", "bbone_curveoutz",
"bbone_rollin", "bbone_rollout",
"bbone_scalein", "bbone_scaleout",
"bbone_easein", "bbone_easeout",
]
BBONE_PROPS_LENGTHS = {
"bbone_curveinx": 1,
"bbone_curveoutx": 1,
"bbone_curveinz": 1,
"bbone_curveoutz": 1,
"bbone_rollin": 1,
"bbone_rollout": 1,
"bbone_scalein": 3,
"bbone_scaleout": 3,
"bbone_easein": 1,
"bbone_easeout": 1,
}
def can_be_keyed(value):
"""Returns a tri-state boolean.
- True: known to be keyable.
- False: known to not be keyable.
- None: unknown, might be an enum property for which RNA uses a string to
indicate a specific item (keyable) or an actual string property (not
keyable).
"""
if isinstance(value, (int, float, bool)):
# These types are certainly keyable.
return True
if isinstance(value, (list, tuple, set, dict)):
# These types are certainly not keyable.
return False
# Maybe this could be made stricter, as also ID pointer properties and
# some other types cannot be keyed. However, the above checks are enough
# to fix the crash that this code was written for (#117988).
return None
# Convert rna_prop types (IDPropertyArray, etc) to python types.
def clean_custom_properties(obj):
if not bake_options.do_custom_props:
# Don't bother remembering any custom properties when they're not
# going to be baked anyway.
return {}
# Be careful about which properties to actually consider for baking, as
# keeping references to complex Blender data-structures around for too long
# can cause crashes. See #117988.
clean_props = {
key: rna_idprop_value_to_python(value)
for key, value in obj.items()
if can_be_keyed(value) is not False
}
return clean_props
def bake_custom_properties(obj, *, custom_props, frame, group_name=""):
import idprop
if frame is None or not custom_props:
return
for key, value in custom_props.items():
if key in obj.bl_rna.properties and not obj.bl_rna.properties[key].is_animatable:
continue
if isinstance(obj[key], idprop.types.IDPropertyGroup):
continue
obj[key] = value
# The check for `is_runtime` is needed in case the custom property has the same
# name as a built in property, e.g. `scale`. In that case the simple check
# `key in ...` would be true and the square brackets would never get added.
if key in obj.bl_rna.properties and obj.bl_rna.properties[key].is_runtime:
rna_path = key
else:
rna_path = "[\"{:s}\"]".format(bpy.utils.escape_identifier(key))
try:
obj.keyframe_insert(rna_path, frame=frame, group=group_name)
except TypeError:
# The is_animatable check above is per property. A property in isolation
# may be considered animatable, but it could be owned by a data-block that
# itself cannot be animated.
continue
def pose_frame_info(obj):
matrix = {}
bbones = {}
custom_props = {}
for name, pbone in obj.pose.bones.items():
if bake_options.do_visual_keying:
# Get the final transform of the bone in its own local space...
matrix[name] = obj.convert_space(pose_bone=pbone, matrix=pbone.matrix,
from_space='POSE', to_space='LOCAL')
else:
matrix[name] = pbone.matrix_basis.copy()
# Bendy Bones
if pbone.bone.bbone_segments > 1:
bbones[name] = {bb_prop: getattr(pbone, bb_prop) for bb_prop in BBONE_PROPS}
# Custom Properties
custom_props[name] = clean_custom_properties(pbone)
return matrix, bbones, custom_props
def armature_frame_info(obj):
if obj.type != 'ARMATURE':
return {}
return clean_custom_properties(obj)
if bake_options.do_parents_clear:
if bake_options.do_visual_keying:
def obj_frame_info(obj):
return obj.matrix_world.copy(), clean_custom_properties(obj)
else:
def obj_frame_info(obj):
parent = obj.parent
matrix = obj.matrix_basis
if parent:
return parent.matrix_world @ matrix, clean_custom_properties(obj)
else:
return matrix.copy(), clean_custom_properties(obj)
else:
if bake_options.do_visual_keying:
def obj_frame_info(obj):
parent = obj.parent
matrix = obj.matrix_world
if parent:
return parent.matrix_world.inverted_safe() @ matrix, clean_custom_properties(obj)
else:
return matrix.copy(), clean_custom_properties(obj)
else:
def obj_frame_info(obj):
return obj.matrix_basis.copy(), clean_custom_properties(obj)
# -------------------------------------------------------------------------
# Setup the Context
if obj.pose is None:
bake_options.do_pose = False
if not (bake_options.do_pose or bake_options.do_object):
raise Exception("Pose and object baking is disabled, no action needed")
pose_info = []
armature_info = []
obj_info = []
# -------------------------------------------------------------------------
# Collect transformations
while True:
# Caller is responsible for setting the frame and updating the scene.
frame = yield None
# Signal we're done!
if frame is None:
break
if bake_options.do_pose:
pose_info.append((frame, *pose_frame_info(obj)))
armature_info.append((frame, armature_frame_info(obj)))
if bake_options.do_object:
obj_info.append((frame, *obj_frame_info(obj)))
# -------------------------------------------------------------------------
# Clean (store initial data)
if bake_options.do_clean and action is not None:
clean_orig_data = {fcu: {p.co[1] for p in fcu.keyframe_points} for fcu in action.fcurves}
else:
clean_orig_data = {}
# -------------------------------------------------------------------------
# Create action
# in case animation data hasn't been created
atd = obj.animation_data_create()
old_slot_name = atd.last_slot_identifier[2:]
is_new_action = action is None
if is_new_action:
action = bpy.data.actions.new("Action")
# Only leave tweak mode if we actually need to modify the action (#57159)
if action != atd.action:
# Leave tweak mode before trying to modify the action (#48397)
if atd.use_tweak_mode:
atd.use_tweak_mode = False
atd.action = action
# A slot needs to be assigned.
if not atd.action_slot:
slot = action.slots.new(obj.id_type, old_slot_name or obj.name)
atd.action_slot = slot
# Baking the action only makes sense in Replace mode, so force it (#69105)
if not atd.use_tweak_mode:
atd.action_blend_type = 'REPLACE'
# -------------------------------------------------------------------------
# Apply transformations to action
# pose
lookup_fcurves = {}
assert action.is_action_layered
channelbag = action_get_channelbag_for_slot(action, atd.action_slot)
if channelbag:
# channelbag can be None if no layers or strips exist in the action.
lookup_fcurves = {(fcurve.data_path, fcurve.array_index): fcurve for fcurve in channelbag.fcurves}
if bake_options.do_pose:
for f, armature_custom_properties in armature_info:
bake_custom_properties(obj, custom_props=armature_custom_properties,
frame=f, group_name="Armature Custom Properties")
for name, pbone in obj.pose.bones.items():
if bake_options.only_selected and not pbone.bone.select:
continue
if bake_options.do_constraint_clear:
while pbone.constraints:
pbone.constraints.remove(pbone.constraints[0])
# Create compatible euler & quaternion rotation values.
euler_prev = None
quat_prev = None
base_fcurve_path = pbone.path_from_id() + "."
path_location = base_fcurve_path + "location"
path_quaternion = base_fcurve_path + "rotation_quaternion"
path_axis_angle = base_fcurve_path + "rotation_axis_angle"
path_euler = base_fcurve_path + "rotation_euler"
path_scale = base_fcurve_path + "scale"
paths_bbprops = [(base_fcurve_path + bbprop) for bbprop in BBONE_PROPS]
keyframes = KeyframesCo()
if bake_options.do_location:
keyframes.add_paths(path_location, 3)
if bake_options.do_rotation:
keyframes.add_paths(path_quaternion, 4)
keyframes.add_paths(path_axis_angle, 4)
keyframes.add_paths(path_euler, 3)
if bake_options.do_scale:
keyframes.add_paths(path_scale, 3)
if bake_options.do_bbone and pbone.bone.bbone_segments > 1:
for prop_name, path in zip(BBONE_PROPS, paths_bbprops):
keyframes.add_paths(path, BBONE_PROPS_LENGTHS[prop_name])
rotation_mode = pbone.rotation_mode
total_new_keys = len(pose_info)
for (f, matrix, bbones, custom_props) in pose_info:
pbone.matrix_basis = matrix[name].copy()
if bake_options.do_location:
keyframes.extend_co_values(path_location, 3, f, pbone.location)
if bake_options.do_rotation:
if rotation_mode == 'QUATERNION':
if quat_prev is not None:
quat = pbone.rotation_quaternion.copy()
quat.make_compatible(quat_prev)
pbone.rotation_quaternion = quat
quat_prev = quat
del quat
else:
quat_prev = pbone.rotation_quaternion.copy()
keyframes.extend_co_values(path_quaternion, 4, f, pbone.rotation_quaternion)
elif rotation_mode == 'AXIS_ANGLE':
keyframes.extend_co_values(path_axis_angle, 4, f, pbone.rotation_axis_angle)
else: # euler, XYZ, ZXY etc
if euler_prev is not None:
euler = pbone.matrix_basis.to_euler(pbone.rotation_mode, euler_prev)
pbone.rotation_euler = euler
del euler
euler_prev = pbone.rotation_euler.copy()
keyframes.extend_co_values(path_euler, 3, f, pbone.rotation_euler)
if bake_options.do_scale:
keyframes.extend_co_values(path_scale, 3, f, pbone.scale)
# Bendy Bones
if bake_options.do_bbone and pbone.bone.bbone_segments > 1:
bbone_shape = bbones[name]
for prop_index, prop_name in enumerate(BBONE_PROPS):
prop_len = BBONE_PROPS_LENGTHS[prop_name]
if prop_len > 1:
keyframes.extend_co_values(
paths_bbprops[prop_index], prop_len, f, bbone_shape[prop_name]
)
else:
keyframes.extend_co_value(
paths_bbprops[prop_index], f, bbone_shape[prop_name]
)
# Custom Properties
if bake_options.do_custom_props:
bake_custom_properties(pbone, custom_props=custom_props[name], frame=f, group_name=name)
if is_new_action:
keyframes.insert_keyframes_into_new_action(total_new_keys, action, name)
else:
keyframes.insert_keyframes_into_existing_action(
lookup_fcurves, total_new_keys, action, atd.action_slot)
# object. TODO. multiple objects
if bake_options.do_object:
if bake_options.do_constraint_clear:
while obj.constraints:
obj.constraints.remove(obj.constraints[0])
# Create compatible euler & quaternion rotations.
euler_prev = None
quat_prev = None
path_location = "location"
path_quaternion = "rotation_quaternion"
path_axis_angle = "rotation_axis_angle"
path_euler = "rotation_euler"
path_scale = "scale"
keyframes = KeyframesCo()
if bake_options.do_location:
keyframes.add_paths(path_location, 3)
if bake_options.do_rotation:
keyframes.add_paths(path_quaternion, 4)
keyframes.add_paths(path_axis_angle, 4)
keyframes.add_paths(path_euler, 3)
if bake_options.do_scale:
keyframes.add_paths(path_scale, 3)
rotation_mode = obj.rotation_mode
total_new_keys = len(obj_info)
for (f, matrix, custom_props) in obj_info:
name = "Action Bake" # XXX: placeholder
obj.matrix_basis = matrix
if bake_options.do_location:
keyframes.extend_co_values(path_location, 3, f, obj.location)
if bake_options.do_rotation:
if rotation_mode == 'QUATERNION':
if quat_prev is not None:
quat = obj.rotation_quaternion.copy()
quat.make_compatible(quat_prev)
obj.rotation_quaternion = quat
quat_prev = quat
del quat
else:
quat_prev = obj.rotation_quaternion.copy()
keyframes.extend_co_values(path_quaternion, 4, f, obj.rotation_quaternion)
elif rotation_mode == 'AXIS_ANGLE':
keyframes.extend_co_values(path_axis_angle, 4, f, obj.rotation_axis_angle)
else: # euler, XYZ, ZXY etc
if euler_prev is not None:
obj.rotation_euler = matrix.to_euler(obj.rotation_mode, euler_prev)
euler_prev = obj.rotation_euler.copy()
keyframes.extend_co_values(path_euler, 3, f, obj.rotation_euler)
if bake_options.do_scale:
keyframes.extend_co_values(path_scale, 3, f, obj.scale)
if bake_options.do_custom_props:
bake_custom_properties(obj, custom_props=custom_props, frame=f, group_name=name)
if is_new_action:
keyframes.insert_keyframes_into_new_action(total_new_keys, action, name)
else:
keyframes.insert_keyframes_into_existing_action(
lookup_fcurves, total_new_keys, action, atd.action_slot)
if bake_options.do_parents_clear:
obj.parent = None
# -------------------------------------------------------------------------
# Clean
if bake_options.do_clean:
for fcu in action.fcurves:
fcu_orig_data = clean_orig_data.get(fcu, set())
keyframe_points = fcu.keyframe_points
i = 1
while i < len(keyframe_points) - 1:
val = keyframe_points[i].co[1]
if val in fcu_orig_data:
i += 1
continue
val_prev = keyframe_points[i - 1].co[1]
val_next = keyframe_points[i + 1].co[1]
if abs(val - val_prev) + abs(val - val_next) < 0.0001:
keyframe_points.remove(keyframe_points[i])
else:
i += 1
yield action
class KeyframesCo:
"""
A buffer for keyframe Co unpacked values per ``FCurveKey``. ``FCurveKeys`` are added using
``add_paths()``, Co values stored using extend_co_values(), then finally use
``insert_keyframes_into_*_action()`` for efficiently inserting keys into the F-curves.
Users are limited to one Action Group per instance.
"""
__slots__ = (
"keyframes_from_fcurve",
)
# `keyframes[(rna_path, array_index)] = list(time0,value0, time1,value1,...)`.
keyframes_from_fcurve: Mapping[FCurveKey, ListKeyframes]
def __init__(self):
self.keyframes_from_fcurve = {}
def add_paths(
self,
rna_path: str,
total_indices: int,
) -> None:
keyframes_from_fcurve = self.keyframes_from_fcurve
for array_index in range(0, total_indices):
keyframes_from_fcurve[(rna_path, array_index)] = []
def extend_co_values(
self,
rna_path: str,
total_indices: int,
frame: float,
values: Sequence[float],
) -> None:
keyframes_from_fcurve = self.keyframes_from_fcurve
for array_index in range(0, total_indices):
keyframes_from_fcurve[(rna_path, array_index)].extend((frame, values[array_index]))
def extend_co_value(
self,
rna_path: str,
frame: float,
value: float,
) -> None:
self.keyframes_from_fcurve[(rna_path, 0)].extend((frame, value))
def insert_keyframes_into_new_action(
self,
total_new_keys: int,
action: Action,
action_group_name: str,
) -> None:
"""
Assumes the action is new, that it has no F-curves. Otherwise, the only difference between versions is
performance and implementation simplicity.
:arg action_group_name: Name of Action Group that F-curves are added to.
:type action_group_name: str
"""
linear_enum_values = [
bpy.types.Keyframe.bl_rna.properties["interpolation"].enum_items["LINEAR"].value
] * total_new_keys
for fc_key, key_values in self.keyframes_from_fcurve.items():
if len(key_values) == 0:
continue
data_path, array_index = fc_key
keyframe_points = action.fcurves.new(
data_path, index=array_index, action_group=action_group_name
).keyframe_points
keyframe_points.add(total_new_keys)
keyframe_points.foreach_set("co", key_values)
keyframe_points.foreach_set("interpolation", linear_enum_values)
# There's no need to do fcurve.update() because the keys are already ordered, have
# no duplicates and all handles are Linear.
def insert_keyframes_into_existing_action(
self,
lookup_fcurves: Mapping[FCurveKey, bpy.types.FCurve],
total_new_keys: int,
action: Action,
action_slot: ActionSlot,
) -> None:
"""
Assumes the action already exists, that it might already have F-curves. Otherwise, the
only difference between versions is performance and implementation simplicity.
:arg lookup_fcurves: : This is only used for efficiency.
It's a substitute for ``action.fcurves.find()`` which is a potentially expensive linear search.
:type lookup_fcurves: ``Mapping[FCurveKey, bpy.types.FCurve]``
:arg action_group_name: Name of Action Group that F-curves are added to.
:type action_group_name: str
"""
linear_enum_values = [
bpy.types.Keyframe.bl_rna.properties["interpolation"].enum_items["LINEAR"].value
] * total_new_keys
for fc_key, key_values in self.keyframes_from_fcurve.items():
if len(key_values) == 0:
continue
fcurve = lookup_fcurves.get(fc_key, None)
if fcurve is None:
data_path, array_index = fc_key
assert action.is_action_layered
channelbag = _ensure_channelbag_exists(action, action_slot)
fcurve = channelbag.fcurves.new(data_path, index=array_index)
keyframe_points = fcurve.keyframe_points
co_buffer = [0] * (2 * len(keyframe_points))
keyframe_points.foreach_get("co", co_buffer)
co_buffer.extend(key_values)
ipo_buffer = [None] * len(keyframe_points)
keyframe_points.foreach_get("interpolation", ipo_buffer)
ipo_buffer.extend(linear_enum_values)
# XXX: Currently baking inserts the same number of keys for all baked properties.
# This block of code breaks if that's no longer true since we then will not be properly
# initializing all the data.
keyframe_points.add(total_new_keys)
keyframe_points.foreach_set("co", co_buffer)
keyframe_points.foreach_set("interpolation", ipo_buffer)
# This also deduplicates keys where baked keys were inserted on the
# same frame as existing ones.
fcurve.update()