Animate an already-rigged mesh in Blender: set actions and keyframes, shape timing in the Dope Sheet, refine curves and easing in the Graph Editor, build motion cycles, layer clips in the NLA, and check the result with preview renders. Use when a finished rig needs motion, from a single posed action to a small set of layered clips.
./install.sh blender-animation-basicsname: blender_animation_basics description: "Animate an already-rigged mesh in Blender: set actions and keyframes, shape timing in the Dope Sheet, refine curves and easing in the Graph Editor, build motion cycles, layer clips in the NLA, and check the result with preview renders. Use when a finished rig needs motion, from a single posed action to a small set of layered clips." metadata: { "includeInPrompt": true }
Take a finished, tested rig and make it move: pose the control bones, set keyframes into named actions, shape the timing and the curves until the motion reads, build repeating cycles where the motion loops, layer finished actions in the NLA, and prove the result with preview renders. This skill covers animation only, from a rigged file to timed, checked motion.
Scope:
blender -b --factory-startup -noaudio --python script.py). Operator names quoted below were checked against the actual operator lists of that build, because a name that merely looks right can still not exist. Property names, enum values, and the audit numbers were produced by real runs, not recalled. Blender changes operator and property names between major versions, and 5.1.1 uses layered actions, so re-check a quoted name in the Python console of a newer build before relying on it.frame_start and frame_end on the scene, and fps on scene.render. Timing decisions are frame counts, and a frame rate changed halfway through retimes every action in the file. The audit script in the Operating Rules section prints both values at the top of its output.use_preview_range, frame_preview_start, and frame_preview_end exist in 5.1.1. A preview range left on from an earlier task makes playback and preview renders stop early and look like missing animation. The audit prints use_preview_range so the state is on record.scene.timeline_markers.new(name, frame) works headless in 5.1.1, and markers give the timing sheet in the output contract something fixed to refer to.bpy.ops.action.new and bpy.ops.action.duplicate exist in 5.1.1) or by keying with a fresh action assigned. In Python, bpy.data.actions.new(name) creates one and assigning it to rig.animation_data.action makes it active; both were run in 5.1.1 for the audit fixture.Bounce, Idle, or WalkCycle, never for their version. Action.001 in a delivered file means nobody can tell what plays where, and the NLA layout in the output contract is built from these names.use_fake_user on every action you intend to keep. The property exists on the Action data block in 5.1.1. An action with no users and no fake user is dropped when the file is saved and reloaded, which is the standard way finished clips quietly vanish from a project.action.fcurves directly will fail or read nothing on a layered action. The audit script shows the working read path: layers, then strips, then channelbags, then fcurves, with the legacy action.fcurves collection used only when action.is_action_layered is false.bpy.ops.anim.keyframe_insert_menu; in Python, keyframe_insert(data_path, frame) on the pose bone or object inserts the key and returns True on success, verified in 5.1.1. Scripted inserts work headless; most editor operators in steps 5 and 6 need their editor open and are UI steps.keyframe_insert with no index keys every component of the property (a location key writes X, Y, and Z curves); passing index keys one component only. The audit fixture keys rotation_euler with index=0 to animate one rotation axis without creating flat curves for the other two. Flat curves for channels that never move are noise the Graph Editor pass has to wade through.keyframe_new_interpolation_type reads BEZIER and keyframe_new_handle_type reads AUTO_CLAMPED on a factory startup. Blocking passes often want LINEAR or CONSTANT instead (step 5). Set the preference before a blocking pass instead of fixing every key after.use_keyframe_insert_auto (auto-keying), auto_keying_mode, use_keyframe_insert_keyingset, and use_keyframe_cycle_aware. Auto-keying while exploring poses fills actions with keys nobody chose. Keep it off during blocking unless the pass is specifically a capture pass.use_visual_keying exists in 5.1.1 and matters on constrained and IK-driven bones: visual keying keys the result the rig actually shows instead of the raw channel value. If a constrained bone plays back differently from the pose you set, check this setting before re-posing.bpy.ops.pose.breakdown, bpy.ops.pose.push, bpy.ops.pose.relax, bpy.ops.pose.propagate, and bpy.ops.pose.blend_to_neighbor all exist in 5.1.1 and operate on the selected pose bones between their neighbouring keys.bpy.ops.pose.paths_calculate and bpy.ops.object.paths_calculate exist in 5.1.1. Even spacing along the path reads as even motion; bunched points read as a slow patch, whatever the curves say.type property with values KEYFRAME, BREAKDOWN, MOVING_HOLD, EXTREME, JITTER, and GENERATED in 5.1.1, set in the editor with bpy.ops.action.keyframe_type. Breakdowns and extremes marked during blocking tell the next editor which keys are structural and which are disposable.bpy.ops.screen.keyframe_jump and bpy.ops.screen.frame_jump exist in 5.1.1, and bpy.ops.screen.animation_play starts and stops playback. Judge blocking at full playback speed, not by dragging the playhead, because slow scrubbing forgives timing that real playback exposes.bpy.ops.action.mirror and bpy.ops.graph.mirror exist in 5.1.1. Mirroring copies timing errors as faithfully as it copies good timing, so mirror only after the first half plays well on its own.data_path plus an array_index, holding keyframe points with a frame and a value (co), an interpolation mode, an easing, and two handles. Read curves this way when scripting; the audit lists data paths per action so you can confirm the curves that exist are the curves the motion needs.bpy.ops.graph.interpolation_type sets it on selected keys and bpy.ops.action.interpolation_type does the same from the Dope Sheet side; both exist in 5.1.1.bpy.ops.graph.handle_type. AUTO_CLAMPED is the safe default for body motion because it resists overshoot at keys. Overshoot past a key value on a location curve is the foot going through the floor or the hand passing the prop it was reaching for.extrapolation property: CONSTANT holds the end value, LINEAR continues the end slope. In the editor this is bpy.ops.graph.extrapolation_type. LINEAR extrapolation on a curve whose last segment still moves will run away during an NLA hold or a long shot, so it needs a deliberate reason.bpy.ops.graph.clean exists in 5.1.1 and takes a threshold value; it removes keys that add no shape, which is the standard state of a curve after auto-keying or a bake. bpy.ops.action.clean does the same job from the action side. Clean first, then shape, because shaping a curve that still carries junk keys wastes the shaping.bpy.ops.graph.euler_filter exists in 5.1.1 and fixes the frame where a rotation jumps by a full turn between neighbouring keys, which shows up in playback as a limb spinning once for no reason.bpy.ops.graph.smooth, bpy.ops.graph.gaussian_smooth, and bpy.ops.graph.butterworth_smooth exist in 5.1.1. Smoothing a curve that was keyed deliberately flattens the accents the animator put in, so smooth baked or captured curves, not hand-keyed ones.easing property: AUTO, EASE_IN, EASE_OUT, and EASE_IN_OUT in 5.1.1. In the editor, bpy.ops.graph.easing_type sets it on selected keys. AUTO is a starting point, not a decision.back for the overshoot amount on BACK, and amplitude and period for BOUNCE and ELASTIC. All three exist on the Keyframe type in 5.1.1. These are accent tools for one segment, such as a landing squash or a springy antenna. A whole action built on them reads as rubber, and they never substitute for correctly timed keys.fc.modifiers.new(type="CYCLES") in Python or bpy.ops.graph.fmodifier_add in the editor, both present in 5.1.1. Its mode_before and mode_after properties take NONE, REPEAT, REPEAT_OFFSET, and MIRROR. REPEAT replays the keyed range as-is (right for a walk), REPEAT_OFFSET adds the cycle's net change each repeat (right for a channel that must keep travelling), and MIRROR plays the range forward then backward (right for a sway or a bob built from half a cycle).use_cyclic exists in 5.1.1 and flags the action as cyclic. Use one route per cycle, not both: a Cycles modifier inside an action that the NLA also repeats produces a loop inside a loop and a seam nobody can locate.animation_data holds nla_tracks; each track holds strips; each strip points at an action. In Python, nla_tracks.new() creates a track and track.strips.new(name, start, action) creates a strip; both were run in 5.1.1 for the audit fixture. In the editor, bpy.ops.nla.tracks_add and bpy.ops.nla.actionclip_add exist.bpy.ops.action.push_down and bpy.ops.nla.action_pushdown push the active action down into a track, and bpy.ops.action.stash stashes it as a muted strip for later use; all exist in 5.1.1. After a push down, the pushed action no longer drives the object directly, which surprises people mid-task: check the active action state after every push down.frame_start and frame_end (where the strip sits and how long it plays), action_frame_start and action_frame_end (which part of the action it plays), repeat (how many times the action cycles inside the strip), and scale (the strip's time stretch). Verified in the fixture run: a strip placed at frame 1 over an action whose keys run 1 to 60, with repeat set to 2.0, reported strip frames 1 to 119.blend_type values in 5.1.1 are REPLACE, COMBINE, ADD, SUBTRACT, and MULTIPLY, and extrapolation values are NOTHING, HOLD, and HOLD_FORWARD. REPLACE on the bottom track is the base motion. ADD suits an offset layer, such as a breathing cycle added over an idle. HOLD_FORWARD keeps the last pose after the strip ends; NOTHING lets the character snap back, which is right only when a later strip takes over on the next frame.blend_in and blend_out on the strip for joins between clips instead of keying a manual transition, and use_auto_blend when the overlap of two strips should set the blend. A join with no blend and mismatched end poses pops exactly like a bad loop seam, and the fix is the same order: match the poses first, then set the blend.mute and is_solo on the track, mute on the strip) in 5.1.1. Debug layering problems by soloing one track at a time and playing it, rather than deleting strips to see what changes. What each layer contributes should be statable in one sentence per track in the NLA layout section of the output.bpy.ops.nla.tweakmode_enter and bpy.ops.nla.tweakmode_exit exist in 5.1.1. Keys inserted outside tweak mode while NLA tracks are driving the object land somewhere other than the strip you are looking at, which is the most common cause of "my edit did nothing" in NLA work. Check whether tweak mode is on before keying an object that has strips.bpy.ops.nla.bake exists in 5.1.1 and takes frame_start, frame_end, step, only_selected, visual_keying, clear_constraints, clear_parents, use_current_action, clean_curves, bake_types, and channel_types. Use it when handing motion to a target that cannot evaluate the rig or the NLA stack, or when a segment must be frozen exactly as it plays now.bpy.ops.graph.clean with a threshold is the tool that thins it back out.bpy.ops.graph.keys_to_samples and bpy.ops.graph.samples_to_keys, both in 5.1.1. Samples suit noise and audio-driven curves; keys suit anything a person will edit later.bpy.ops.render.render on a scene with no camera errors with "Cannot render, no camera" and writes nothing. If a file arrives with no camera, add a simple one for checking and say so in the summary, rather than skipping the render check.scene.cycles.samples at 1, a small resolution such as 64 by 36 for a contact check or a few hundred pixels wide for a pose check, and the same settings for every frame in the pass so frames are comparable. Verified run on 5.1.1 headless: with a camera and a point light added, engine CYCLES, device CPU, 1 sample, 64 by 36, bpy.ops.render.render(write_still=True) at frame 13 wrote a 3.1 KB PNG. EEVEE is not used for headless checks here because it fails in this headless environment.scene.frame_set(frame) before each still, set scene.render.filepath to a folder and name you will actually look in, and confirm the output file exists and is non-empty after the render call. A render call that returns without an error and without a file is a failed check, not a passed one. Note that the file written may not carry the frame number in its name, so check the actual output path the render reports.bpy.ops.render.opengl exists in 5.1.1) is a valid second check from inside the UI, where a viewport exists. Do not script it headless; it needs that viewport context.use_fake_user; an action you do not keep gets deleted, not renamed to old.Deliver the animated Blender file plus an animation summary with these sections, in this order:
use_fake_user is set.bpy.ops name and property quoted in this skill was checked in Blender 5.1.1 as described in the Purpose section. Operator existence was checked against the build's operator lists, not by attribute access, which reports names that do not exist. When working in a different version, confirm a name in that build before scripting with it, and soften any instruction that depends on a name you could not confirm.Run: blender -b --factory-startup -noaudio --python anim_audit.py -- --make-fixture reproduces the worked example. Run it on a real file with blender -b your_file.blend -noaudio --python anim_audit.py. On the default factory scene it prints the scene range 1 to 250, fps 24, and zero actions, markers, and animated objects.
Expected output for the fixture run:
text
scene_frame_range: 1-48
fps: 24
use_preview_range: False
markers: 2: apex@13, landing@25
actions: 2
action: Bounce
layered: True
use_cyclic: False
fcurves: 4
data_paths: pose.bones["root"].location, pose.bones["spine"].rotation_euler
keyframes: 12
first_key: 1
last_key: 60
interpolations: BEZIER: 10, LINEAR: 2
keyframe_types: BREAKDOWN: 1, KEYFRAME: 11
modifiers: 1: pose.bones["root"].location[2]: CYCLES
keys_outside_scene_range: 1
non_integer_keyframes: 0
action: Idle
layered: True
use_cyclic: False
fcurves: 1
data_paths: pose.bones["arm.L"].location
keyframes: 3
first_key: 1
last_key: 25
interpolations: CONSTANT: 3
keyframe_types: KEYFRAME: 3
modifiers: 0: -
keys_outside_scene_range: 0
non_integer_keyframes: 1
animated_objects: 1
object: AnimRig
active_action: Bounce
use_nla: True
nla_tracks: 1
track: NlaTrack: mute=False
strip: BounceStrip: action=Bounce frames=1-119 repeat=2 scale=1 blend=REPLACE extrapolation=HOLD mute=False
The fixture is built to trip each check once: Bounce carries one key at frame 60, past the scene end of 48, and one BREAKDOWN key at frame 13; its spine curve is LINEAR while its root curves stay BEZIER; its root Z curve carries the single CYCLES modifier. Idle carries one key at frame 12.5, the non-integer case, with all keys set to CONSTANT. The BounceStrip repeats Bounce twice, which is why its strip frames run 1 to 119 over an action whose keys span 1 to 60.
anim_audit.py (run inside Blender 5.1.1, uses only bpy and sys). Save this code as anim_audit.py:
python
#!/usr/bin/env python3
"""Audit animation in the current Blender scene. Blender 5.1.1.
Run inside Blender, not with system python:
blender -b --factory-startup -noaudio --python anim_audit.py
blender -b your_file.blend -noaudio --python anim_audit.py
Reproduce the worked example in the skill (builds a fixture first):
blender -b --factory-startup -noaudio --python anim_audit.py -- --make-fixture
What it reports: scene frame range and fps, timeline markers, every
action with its F-Curves, keyframe counts, interpolation and keyframe
type counts, F-Curve modifiers, keys outside the scene range and keys
on non-integer frames, and per animated object its active action and
NLA tracks and strips. F-Curves are read through the layered action
channelbags when the action is layered, and through the legacy
fcurves collection when it is not.
"""
import sys
import bpy
def fmt(v):
return f"{v:g}"
def make_fixture():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 48
scene.render.fps = 24
arm_data = bpy.data.armatures.new("AnimRigData")
rig = bpy.data.objects.new("AnimRig", arm_data)
bpy.context.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
bpy.ops.object.mode_set(mode="EDIT")
specs = [
("root", (0, 0, 0), (0, 0, 1), None),
("spine", (0, 0, 1), (0, 0, 2), "root"),
("arm.L", (0, 0, 2), (1, 0, 2), "spine"),
]
made = {}
for name, head, tail, parent in specs:
eb = arm_data.edit_bones.new(name)
eb.head = head
eb.tail = tail
made[name] = eb
for name, head, tail, parent in specs:
if parent:
made[name].parent = made[parent]
bpy.ops.object.mode_set(mode="POSE")
root = rig.pose.bones["root"]
for frame, z in [(1, 0.0), (13, 0.5), (25, 0.0)]:
root.location = (0, 0, z)
root.keyframe_insert(data_path="location", frame=frame)
spine = rig.pose.bones["spine"]
for frame, ang in [(1, 0.0), (25, 0.261799)]:
spine.rotation_euler = (ang, 0, 0)
spine.keyframe_insert(data_path="rotation_euler", frame=frame, index=0)
bounce = rig.animation_data.action
bounce.name = "Bounce"
for fc in fcurves_of(bounce):
if fc.data_path == 'pose.bones["spine"].rotation_euler':
for kp in fc.keyframe_points:
kp.interpolation = "LINEAR"
fc.update()
if fc.data_path == 'pose.bones["root"].location' and fc.array_index == 2:
for kp in fc.keyframe_points:
if kp.co.x == 13.0:
kp.type = "BREAKDOWN"
mod = fc.modifiers.new(type="CYCLES")
mod.mode_before = "REPEAT"
mod.mode_after = "REPEAT"
root.location = (0, 0, 0.0)
root.keyframe_insert(data_path="location", frame=60, index=2)
idle = bpy.data.actions.new("Idle")
rig.animation_data.action = idle
arm = rig.pose.bones["arm.L"]
for frame, x in [(1, 0.0), (12.5, 0.05), (25, 0.1)]:
arm.location = (x, 0, 0)
arm.keyframe_insert(data_path="location", frame=frame, index=0)
for fc in fcurves_of(idle):
for kp in fc.keyframe_points:
kp.interpolation = "CONSTANT"
fc.update()
rig.animation_data.action = bounce
track = rig.animation_data.nla_tracks.new()
strip = track.strips.new(name="BounceStrip", start=1, action=bounce)
strip.repeat = 2.0
scene.timeline_markers.new(name="apex", frame=13)
scene.timeline_markers.new(name="landing", frame=25)
return rig
def fcurves_of(action):
out = []
if action.is_action_layered:
for layer in action.layers:
for strip in layer.strips:
for channelbag in strip.channelbags:
out.extend(list(channelbag.fcurves))
else:
out.extend(list(action.fcurves))
return sorted(out, key=lambda f: (f.data_path, f.array_index))
def audit():
scene = bpy.context.scene
print(f"scene_frame_range: {scene.frame_start}-{scene.frame_end}")
print(f"fps: {scene.render.fps}")
print(f"use_preview_range: {scene.use_preview_range}")
markers = sorted(scene.timeline_markers, key=lambda m: (m.frame, m.name))
print(f"markers: {len(markers)}: {', '.join(f'{m.name}@{m.frame}' for m in markers) if markers else '-'}")
actions = sorted(bpy.data.actions, key=lambda a: a.name)
print(f"actions: {len(actions)}")
for action in actions:
fcurves = fcurves_of(action)
total_keys = 0
outside = 0
non_integer = 0
interp_counts = {}
type_counts = {}
mods = []
frames = []
for fc in fcurves:
for kp in fc.keyframe_points:
total_keys += 1
frames.append(kp.co.x)
interp_counts[kp.interpolation] = interp_counts.get(kp.interpolation, 0) + 1
type_counts[kp.type] = type_counts.get(kp.type, 0) + 1
if kp.co.x < scene.frame_start or kp.co.x > scene.frame_end:
outside += 1
if abs(kp.co.x - round(kp.co.x)) > 0.001:
non_integer += 1
for mod in fc.modifiers:
mods.append(f"{fc.data_path}[{fc.array_index}]: {mod.type}")
print(f"action: {action.name}")
print(f" layered: {action.is_action_layered}")
print(f" use_cyclic: {action.use_cyclic}")
print(f" fcurves: {len(fcurves)}")
print(f" data_paths: {', '.join(sorted({fc.data_path for fc in fcurves})) if fcurves else '-'}")
print(f" keyframes: {total_keys}")
print(f" first_key: {fmt(min(frames)) if frames else '-'}")
print(f" last_key: {fmt(max(frames)) if frames else '-'}")
print(f" interpolations: {', '.join(f'{k}: {v}' for k, v in sorted(interp_counts.items())) if interp_counts else '-'}")
print(f" keyframe_types: {', '.join(f'{k}: {v}' for k, v in sorted(type_counts.items())) if type_counts else '-'}")
print(f" modifiers: {len(mods)}: {', '.join(sorted(mods)) if mods else '-'}")
print(f" keys_outside_scene_range: {outside}")
print(f" non_integer_keyframes: {non_integer}")
animated = sorted([o for o in bpy.data.objects if o.animation_data],
key=lambda o: o.name)
print(f"animated_objects: {len(animated)}")
for obj in animated:
ad = obj.animation_data
active = ad.action.name if ad.action else "-"
print(f"object: {obj.name}")
print(f" active_action: {active}")
print(f" use_nla: {ad.use_nla}")
print(f" nla_tracks: {len(ad.nla_tracks)}")
for track in ad.nla_tracks:
print(f" track: {track.name}: mute={track.mute}")
for strip in sorted(track.strips, key=lambda s: (s.frame_start, s.name)):
sact = strip.action.name if strip.action else "-"
print(f" strip: {strip.name}: action={sact} frames={fmt(strip.frame_start)}-{fmt(strip.frame_end)} repeat={fmt(strip.repeat)} scale={fmt(strip.scale)} blend={strip.blend_type} extrapolation={strip.extrapolation} mute={strip.mute}")
def main():
args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--make-fixture" in args:
make_fixture()
audit()
return 0
if __name__ == "__main__":
raise SystemExit(main())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.